Skip to content

ctl C2: send (queue-until-idle) + status + subtree scoping + --here - #3

Merged
JSBtechnologies merged 1 commit into
feat/ctl-c1from
feat/ctl-c2
Aug 30, 2026
Merged

ctl C2: send (queue-until-idle) + status + subtree scoping + --here#3
JSBtechnologies merged 1 commit into
feat/ctl-c1from
feat/ctl-c2

Conversation

@JSBtechnologies

Copy link
Copy Markdown
Contributor

What & why

Second milestone of the amux ctl control plane — a pane can now task and observe the hierarchy, not just build it. Stacked on #2 (C1) — review/merge #2 first; this PR's base is feat/ctl-c1, so its diff is C2-only.

Severe/critical (extends the hosted-agent control surface) → PR + review.

Surface (all still behind --allow-ctl)

amux ctl send <target> <text>     # task a worker → {ok,target,queued}
amux ctl status [<target>]        # one target, or the caller's subtree
amux ctl spawn --here -- <cmd>    # tile the worker beside its caller

How

  • send = queue-until-idle (Decision 4): if the target is mid-turn (agsess Working/WaitingApproval) the text is held; once it reaches WaitingPrompt/Idle amux writes the text, then the Enter after a short beat (mirrors the C0 spike). Unbound/non-agent targets get a 2s fallback so a queue never wedges. Flushed each tick via flush_sends (a PendingSend queue) — non-blocking, no thread.
  • Targets resolve by pane id or role (ctl::resolve_target, ambiguity is a clean error).
  • status folds in live agsess status (reuses the bar's status_for path).
  • --here splits at the caller's pane (layout::split_pane, not just focus) so a lead + ICs share one tiled window.
  • Subtree scoping (Decision 3): send/status on a target are limited to the caller's own subtree; a root/operator pane controls all. Pure ctl::in_subtree guard; out-of-subtree → JSON refusal.

Safety

No change without --allow-ctl. Scoping means a worker can't reach a sibling's team. send never injects mid-turn. Still agents-only spawn (allowlist unchanged).

Verification

python dev.py check green — 119 lib + 46 integration, dep-guard OK, fmt + clippy clean. New coverage: resolve_target (id/role/ambiguous), in_subtree (parent-chain walk), split_pane, send/status build↔parse roundtrips; live e2e: status reply, a real send delivered to a worker (verified across a window switch so the caller's command echo can't false-positive), and --here spawn.

Not in C2 (C3)

ctl kill (subtree teardown), credential delegation scoping, audit log.

…g + --here

Second milestone of the amux-ctl control plane. A pane can now task and observe
the hierarchy, not just build it. Stacked on C1 (feat/ctl-c1).

Added:
- `ctl send <target> <text>` — deliver a task to a worker as a submitted prompt.
  Queue-until-idle (Decision 4): if the target is mid-turn (agsess Working/
  WaitingApproval), the text is held and delivered — text, then Enter after a
  short beat (mirrors the C0 spike) — once it reaches WaitingPrompt/Idle. An
  unbound/non-agent target gets a 2s fallback so the queue never wedges. Target
  is a pane id or role label (ctl::resolve_target).
- `ctl status [target]` — agsess-backed: one target, or the caller's subtree.
- `ctl spawn --here` — tile the worker beside its caller (layout::split_pane
  splits at the caller's pane, not just focus), so a lead + its ICs share a view.
- Subtree scoping (Decision 3): send/status on a target are restricted to the
  caller's subtree; a root/operator pane controls all. Pure ctl::in_subtree
  guard; out-of-subtree is a clean JSON refusal.

Run loop: a PendingSend queue flushed each tick (flush_sends); apply_ctl grew
send/status handling, target resolution, scope_denied, and the --here split
path (spawn_worker_here). No behavior change without --allow-ctl.

Verified: `python dev.py check` green — 119 lib + 46 integration tests. New:
resolve_target (id/role/ambiguous), in_subtree (parent-chain walk), split_pane,
send/status build↔parse roundtrips, and live e2e — status reply, a real send
delivered to a worker (checked across a window switch so the caller's command
echo can't false-positive), and --here spawn. fmt + clippy clean.

Not yet (C3): ctl kill, identity delegation scoping, audit log.
@JSBtechnologies
JSBtechnologies merged commit a3532ca into feat/ctl-c1 Aug 30, 2026
JSBtechnologies added a commit that referenced this pull request Aug 30, 2026
…(0.8.1)

A ctl spawn (new window) and --here split sized the worker's pty to a rough
estimate but never resized the window, so the agent painted short (content at
top, blank below) until a manual terminal resize. Both paths now call
resize_window right after spawning, matching the interactive split handlers.
Fixes founder repaint reports #1 (new-window spawn) and #3 (--here didn't push
to bottom).

Verified: python dev.py check green (119 lib + 46 integ).
JSBtechnologies added a commit that referenced this pull request Sep 4, 2026
…fied on real Windows]

Fills the src/reap.rs cfg(not(unix)) stub with the durable Windows teardown layer
from the design spec: one Job Object per session with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
Every pane is assigned to it; amux holds the sole non-inheritable handle for its
whole life; the kernel terminates every process in the job — children inherit it,
so grandchildren too — the instant that handle closes, however amux exits,
TerminateProcess included. That is the one death mode no unix layer can match.

- SessionJob (src/reap.rs): no-op on unix; on Windows creates the job (kill-on-close,
  non-inheritable handle per R2), assign() adds a pane, Drop closes the handle.
  Win32 externs + constants + repr(C) structs (IoCounters / JobBasic / JobExtended)
  transcribed from winnt.h; usize for SIZE_T/ULONG_PTR (correct on 32- and 64-bit).
- main.rs: session_job created once in run(), held for the session, each new pane
  assigned in the registry loop. spawn_watchdog + its var are now cfg(unix) — on
  Windows the job replaces the watchdog, which there could not signal a group and
  would block on its pipe forever (R5). amux reap stays as the near-inert manual path.
- R1: create/assign failures degrade to a debug warning and a null-handle no-op; a
  pane is never refused. GetLastError captured immediately after each failing call
  (before CloseHandle) per review.

VERIFIED ON REAL WINDOWS (what the spec's macOS author could not do): acceptance
corruption), #2 clean-quit kills the pane's grandchild, #3 taskkill /F of amux
still takes the tree down via kill-on-close, #6 full dev.py check green (237 tests).
R4 confirmed: hard-killing amux leaves no conhost residue. Adversarially reviewed
(rust-reviewer): verdict ship, layout exact on both bitwidths, handle inheritance
correctly prevented, every failure path a safe no-op, no double-close.

Also (pre-existing, distinct): fixed closing_an_overlay_repaints_the_pane, which
shipped in the unix-teardown work typing a bash-only marker into cmd.exe and could
never pass on Windows (confirmed failing at clean 43e7a6e). Branched the marker for
cmd. Exactly the cargo-check-is-not-a-run gap the spec warned about.
JSBtechnologies added a commit that referenced this pull request Sep 4, 2026
…#3, #4]

#3 — `flush_sends` wrote with `let _ = p.pty.write(text)`. `write(2)` returns a
COUNT and may legitimately accept less than offered: a pty's input buffer is
finite, and in canonical mode a single line is capped near 1 KB. The code threw
that count away, marked the send delivered, and 400 ms later wrote the Enter
that submits it — so a long task arrived as a fragment while ctl had already
replied ok:true. The review reproduced it four times on its own dispatches:
~800-byte sends vanished, ~70-byte ones worked.

PendingSend now carries `written`, advances by what was actually accepted, and
stamps `text_written_at` only once the last byte lands, so Enter can never
submit a fragment. Resuming across ticks rather than looping until complete is
deliberate: this runs on the single event loop, and the review's own suggested
`write_all` would block every pane until the target drained its buffer — trading
silent truncation for a session-wide freeze, which is finding #5's class.

#4 — `sanitize_spawn_argv` was a denylist of three literal names, and a denylist
guarding this surface fails by omission. `--allowed-tools` (claude's own
documented alias of the governed `--allowedTools`) passed straight through, and
so did `--mcp-config`, `--plugin-dir`, `--plugin-url` and `--settings`, each of
which reaches code execution OUTSIDE the tool-permission system: an stdio MCP
server is a command claude launches at startup, a plugin carries hooks. None of
those names appears anywhere else in amux, so a worker could pass one verbatim
in every mode, plan included.

Inverted to a per-vendor allowlist. `vet_spawn_argv` refuses an unlisted flag
outright instead of quietly cleaning it, and refuses an agent stem amux has no
trust posture for (gemini, aider, cursor-agent) since it could not cap what such
an agent may do. Governed flags still strip-and-report rather than refuse —
asking for one is not an attack, it is amux's to set.

Scoped narrowly, after a first attempt was too broad: a NON-agent command (sh,
cmd) carries no permission flags to escape through, and whether it may be
spawned at all is the spawn allowlist's decision, not this function's. The first
version refused shells and broke six integration tests that spawn `sh` as a
worker — a supported, tested use. Deferring is both correct and narrower.

macOS 26.6.2: ./dev.py check green — 310 tests, 0 failures, clippy clean.
Type-checks for x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017KoBz1RcGsUbYdMSCR7LaK
@JSBtechnologies
JSBtechnologies deleted the feat/ctl-c2 branch September 7, 2026 08:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant